跳到主要内容

行为型模式-命令模式

转载自 游戏设计模式Design Patterns Revisited(对原文做了微小改动)

什么是命令模式

命令模式将一个请求封装为一个对象,从而使你可用不同的请求对客户进行参数化; 对请求排队或记录请求日志,以及支持可撤销的操作。

就是:命令是具现化(“实例化,对象化”)的方法调用,即命令模式是一种回调的面向对象实现。

命令模式类图

命令模式可以配合观察者模式,使之允许接收者动态地订阅或取消接收请求。

classDiagram Client ..> Invoker Client ..> Receiver Invoker o--> Command Receiver <-- ConcreteCommand ConcreteCommand --|> Command Invoker: -command Invoker: +setCommand(Command) Invoker: +executeCommand() Receiver: +operation() Command: <<interface>> Command: +execute() ConcreteCommand: -Receiver receiver ConcreteCommand: +execute()

命令 (Command) 接口通常仅声明一个执行命令的方法。

public abstract class Command {
protected Receiver receiver;

public Command(Receiver receiver) {
this.receiver = receiver;
}

public abstract void execute();
}

具体命令 (Concrete Commands) 将一个接收者对象绑定于一个 action,调用接收者相应的操作,以实现 execute

public class ConcreteCommand {

public ConcreteCommand(Receiver receiver) {
super(receiver);
}

@Override
public void execute() {
receiver.operation();
}
}

发送者 (Sender)—— 亦称 “触发者 (Invoker)” —— 类负责对请求进行初始化, 其中必须包含一个成员变量来存储对于命令对象的引用。 发送者触发命令, 而不向接收者直接发送请求。 注意, 发送者并不负责创建命令对象: 它通常会通过构造函数从客户端处获得预先生成的命令。(这里的触发器可以使用 “事件” 机制只要触发了某事件就执行这条命令)

public class Invoker {
private Command command;

public void setCommand(Command command) {
this.command = command;
}

public void executeCommand() {
command.execute();
}
}

接收者 (Receiver) 类包含部分业务逻辑。 几乎任何对象都可以作为接收者。 绝大部分命令只处理如何将请求传递到接收者的细节, 接收者自己会完成实际的工作。

public class Receiver {
public void operation() {
System.out.println("执行请求!");
}
}

在客户端调用,创建一个具体命令对象并设定它的接收者

public static void main(String[] args) {
Receiver r = new Receiver();
Command c = new Concrete;
Invoker i = new Invoker();

i.setCommand(c);
i.executeCommand();
}

配置输入的例子

在每个游戏中都有一块代码读取用户的输入——按钮按下,键盘敲击,鼠标点击,诸如此类。 这块代码会获取用户的输入,然后将其变为游戏中有意义的行为:

image.png

下面是一种简单的实现

void InputHandler::handleInput()
{
if (isPressed(BUTTON_X)) jump();
else if (isPressed(BUTTON_Y)) fireGun();
else if (isPressed(BUTTON_A)) swapWeapon();
else if (isPressed(BUTTON_B)) lurchIneffectively();
}

这个函数通常在游戏循环中每帧调用一次,他将用户的输入和程序行为硬编码在一起,但是许多游戏允许玩家配置按键的功能。

为了支持这点,需要将这些对 jump()fireGun() 的直接调用转化为可以变换的东西。即使用命令模式

定义了一个基类代表可触发的游戏行为:

class Command
{
public:
virtual ~Command() {}
virtual void execute() = 0;
};

然后我们为不同的游戏行为定义相应的子类:

class JumpCommand : public Command
{
public:
virtual void execute() { jump(); }
};


class FireCommand : public Command
{
public:
virtual void execute() { fireGun(); }
};

在代码的输入处理部分,为每个按键存储一个指向命令的指针。

class InputHandler
{
public:
void handleInput();

// 绑定命令的方法……

private:
Command* buttonX_;
Command* buttonY_;
Command* buttonA_;
Command* buttonB_;
};

现在输入处理部分这样处理:

void InputHandler::handleInput()
{
if (isPressed(BUTTON_X)) buttonX_->execute();
else if (isPressed(BUTTON_Y)) buttonY_->execute();
else if (isPressed(BUTTON_A)) buttonA_->execute();
else if (isPressed(BUTTON_B)) buttonB_->execute();
}

以前每个输入直接调用函数,现在会有一层间接寻址:

抽离出状态对象

刚才定义的类可以在之前的例子上正常工作,但有很大的局限。 问题在于假设了顶层的 jump(), fireGun() 之类的函数可以找到玩家角色,然后像木偶一样操纵它。

这些假定的耦合限制了这些命令的用处。JumpCommand 只能 让玩家的角色跳跃。让我们放松这个限制。 不让函数去找它们控制的角色,我们 将函数控制的角色对象传进去(就像享元模式那样抽离出外部状态):

class Command
{
public:
virtual ~Command() {}
virtual void execute(GameActor& actor) = 0;
};

这里的 GameActor 是代表游戏世界中角色的 “游戏对象” 类。 我们将其传给 execute(),这样命令类的子类就可以调用所选游戏对象上的方法,就像这样:

class JumpCommand : public Command
{
public:
virtual void execute(GameActor& actor)
{
actor.jump();
}
};

现在,我们可以使用这个类让游戏中的任何角色跳来跳去了。 在输入控制部分和在对象上调用命令部分之间,我们还缺了一块代码。 第一,我们修改 handleInput(),让它可以返回命令:

Command* InputHandler::handleInput()
{
if (isPressed(BUTTON_X)) return buttonX_;
if (isPressed(BUTTON_Y)) return buttonY_;
if (isPressed(BUTTON_A)) return buttonA_;
if (isPressed(BUTTON_B)) return buttonB_;

// 没有按下任何按键,就什么也不做
return NULL;
}

这里不能立即执行,因为还不知道哪个角色会传进来。 这里我们享受了命令是具体调用的好处——延迟到调用执行时再知道。

然后,需要一些接受命令的代码,作用在玩家角色上。像这样:

Command* command = inputHandler.handleInput();
if (command)
{
command->execute(actor);
}

将 actor 视为玩家角色的引用,它会正确地按着玩家的输入移动,所以我们赋予了角色和前面例子中相同的行为。

通过在命令和角色间增加了一层重定向, 我们获得了一个灵巧的功能:我们可以让玩家控制游戏中的任何角色,只需向命令传入不同的角色。

在实践中,这个特性并不经常使用,但是经常会有类似的用例跳出来。 到目前为止,我们只考虑了玩家控制的角色,但是游戏中的其他角色呢? 它们被游戏 AI 控制。我们可以在 AI 和角色之间使用相同的命令模式;AI 代码只需生成 Command 对象。

把控制角色的命令变为第一公民对象,去除直接方法调用中严厉的束缚。 将其视为命令队列,或者是命令流:

image5c56c5a5595ff598.png

一些代码(输入控制器或者AI)产生一系列命令放入流中。 另一些代码(调度器或者角色自身)调用并消耗命令。 通过在中间加入队列,我们解耦了消费者和生产者。

撤销和重做

最后的这个例子是这种模式最广为人知的使用情况。 如果一个命令对象可以做一件事,那么它亦可以撤销这件事。 在一些策略游戏中使用撤销,这样你就可以回滚那些你不喜欢的操作。

没有了命令模式,实现撤销非常困难,有了它,就是小菜一碟。 假设我们在制作单人回合制游戏,想让玩家能撤销移动,这样他们就可以集中注意力在策略上而不是猜测上。

使用了命令来抽象输入控制,所以每个玩家的举动都已经被封装其中。 举个例子,移动一个单位的代码可能如下:

class MoveUnitCommand : public Command
{
public:
MoveUnitCommand(Unit* unit, int x, int y)
: unit_(unit),
x_(x),
y_(y)
{}

virtual void execute()
{
unit_->moveTo(x_, y_);
}

private:
Unit* unit_;
int x_, y_;
};

注意这和前面的命令有些许不同。 在前面的例子中,我们需要从修改的角色那里抽象命令。 在这个例子中,我们将命令绑定到要移动的单位上。 这条命令的实例不是通用的 “移动某物” 命令;而是游戏回合中特殊的一次移动。

这展现了命令模式应用时的一种情形。 就像之前的例子,指令在某些情形中是可重用的对象,代表了可执行的事件。 我们早期的输入控制器将其实现为一个命令对象,然后在按键按下时调用其 execute() 方法。

这里的命令更加特殊。它们代表了特定时间点能做的特定事件。 这意味着输入控制代码可以在玩家下决定时 创造一个实例。就像这样:

Command* handleInput()
{
Unit* unit = getSelectedUnit();

if (isPressed(BUTTON_UP)) {
// 向上移动单位
int destY = unit->y() - 1;
return new MoveUnitCommand(unit, unit->x(), destY);
}

if (isPressed(BUTTON_DOWN)) {
// 向下移动单位
int destY = unit->y() + 1;
return new MoveUnitCommand(unit, unit->x(), destY);
}

// 其他的移动……

return NULL;
}

命令的一次性为我们很快地赢得了一个优点。 为了让指令可被取消,我们为每个类定义另一个需要实现的方法:

class Command
{
public:
virtual ~Command() {}
virtual void execute() = 0;
virtual void undo() = 0;
};

undo() 方法回滚了 execute() 方法造成的游戏状态改变。 这里是添加了撤销功能后的移动命令:

class MoveUnitCommand : public Command
{
public:
MoveUnitCommand(Unit* unit, int x, int y)
: unit_(unit),
xBefore_(0),
yBefore_(0),
x_(x),
y_(y)
{}

virtual void execute()
{
// 保存移动之前的位置
// 这样之后可以复原。

xBefore_ = unit_->x();
yBefore_ = unit_->y();

unit_->moveTo(x_, y_);
}

virtual void undo()
{
unit_->moveTo(xBefore_, yBefore_);
}

private:
Unit* unit_;
int xBefore_, yBefore_;
int x_, y_;
};

注意我们为类添加了更多的状态。 当单位移动时,它忘记了它之前是什么样的。 如果我们想要撤销这个移动,我们需要记得单位之前的状态,也就是 xBefore 和 yBefore 的作用。

为了让玩家撤销移动,我们记录了执行的最后命令。当他们按下 control + z 时,我们调用命令的 undo() 方法。 (如果他们已经撤销了,那么就变成了 “重做”,我们会再一次执行命令)

支持多重的撤销也不太难。 我们不单单记录最后一条指令,还要记录指令列表,然后用一个引用指向 “当前” 的那个。 当玩家执行一条命令,我们将其添加到列表,然后将代表 “当前” 的指针指向它。

当玩家选择 “撤销”,我们撤销现在的命令,将代表当前的指针往后退。 当他们选择 “重做”,我们将代表当前的指针往前进,执行该指令。 如果在撤销后选择了新命令,那么清除命令列表中当前的指针所指命令之后的全部命令(其实上面的队列可以采用栈的结构)。

文本编辑器的例子

文字编辑器和撤销

本例中的文字编辑器在每次用户与其互动时, 都会创建一个新的命令对象。 命令执行其行为后会被压入历史堆栈。

现在, 当程序执行撤销操作时, 它就需要从历史记录中取出最近执行的命令, 然后执行反向操作或者恢复由该命令保存的编辑器历史状态。

OutputDemo.png

commands/Command.java: 抽象基础命令

public abstract class Command {
public Editor editor;
private String backup;

Command(Editor editor) {
this.editor = editor;
}

void backup() {
backup = editor.textField.getText();
}

public void undo() {
editor.textField.setText(backup);
}

public abstract boolean execute();
}

commands/CopyCommand.java: 将所选文字复制到剪贴板

public class CopyCommand extends Command {

public CopyCommand(Editor editor) {
super(editor);
}

@Override
public boolean execute() {
editor.clipboard = editor.textField.getSelectedText();
return false;
}
}

commands/PasteCommand.java: 从剪贴板粘贴文字

public class PasteCommand extends Command {

public PasteCommand(Editor editor) {
super(editor);
}

@Override
public boolean execute() {
if (editor.clipboard == null || editor.clipboard.isEmpty()) return false;
// 把当前的文本保存到 backup 历史里面
backup();
// 再替换当前显示的文本为剪贴板上文本
editor.textField.insert(editor.clipboard, editor.textField.getCaretPosition());
return true;
}
}

commands/CutCommand.java: 将文字剪切到剪贴板

public class CutCommand extends Command {

public CutCommand(Editor editor) {
super(editor);
}

@Override
public boolean execute() {
if (editor.textField.getSelectedText().isEmpty()) return false;

backup();
String source = editor.textField.getText();
// 设置剪贴板上的文本为选中的文本
editor.clipboard = editor.textField.getSelectedText();
editor.textField.setText(cutString(source));
return true;
}

private String cutString(String source) {
String start = source.substring(0, editor.textField.getSelectionStart());
String end = source.substring(editor.textField.getSelectionEnd());
return start + end;
}
}

commands/CommandHistory.java: 命令历史

public class CommandHistory {
// 使用栈来存储
private Stack<Command> history = new Stack<>();

public void push(Command c) {
history.push(c);
}

public Command pop() {
return history.pop();
}

public boolean isEmpty() { return history.isEmpty(); }
}

editor/Editor.java: 文字编辑器的 GUI(使用 Swing),这里的 JButton 就是利用了自带的监听器 addActionListener 做触发器

public class Editor {
public JTextArea textField;
public String clipboard;
private CommandHistory history = new CommandHistory();

public void init() {
JFrame frame = new JFrame("Text editor (type & use buttons, Luke!)");
JPanel content = new JPanel();
frame.setContentPane(content);
frame.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
content.setLayout(new BoxLayout(content, BoxLayout.Y_AXIS));
textField = new JTextArea();
textField.setLineWrap(true);
content.add(textField);
JPanel buttons = new JPanel(new FlowLayout(FlowLayout.CENTER));
JButton ctrlC = new JButton("Ctrl+C");
JButton ctrlX = new JButton("Ctrl+X");
JButton ctrlV = new JButton("Ctrl+V");
JButton ctrlZ = new JButton("Ctrl+Z");
Editor editor = this;
ctrlC.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
executeCommand(new CopyCommand(editor));
}
});
ctrlX.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
executeCommand(new CutCommand(editor));
}
});
ctrlV.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
executeCommand(new PasteCommand(editor));
}
});
ctrlZ.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
undo();
}
});
buttons.add(ctrlC);
buttons.add(ctrlX);
buttons.add(ctrlV);
buttons.add(ctrlZ);
content.add(buttons);
frame.setSize(450, 200);
frame.setLocationRelativeTo(null);
frame.setVisible(true);
}

private void executeCommand(Command command) {
if (command.execute()) {
history.push(command);
}
}

private void undo() {
if (history.isEmpty()) return;

Command command = history.pop();
if (command != null) {
command.undo();
}
}
}

Demo.java: 客户端代码

public class Demo {
public static void main(String[] args) {
Editor editor = new Editor();
editor.init();
}
}